feat(driver-sql,driver-turso): the remaining IDataDriver doors publish their declared types, not any (#15267) - #17258
Conversation
…h their declared types, not any (#15267) `SqlDriver` published an explicit `Promise<any>` over five doors the contract had already declared narrower — `findOne`, `create`, `bulkCreate`, `execute` and `explain` — so the emitted `.d.ts` erased every one of them. `TursoDriver` overrides four of the same five with its own `Promise<any>`, which no driver-sql fix reaches. Each annotation is replaced with the type `packages/spec/src/contracts/data-driver.ts` already declares for that door, and each is pinned at the type level inside its own package's tsc program. No runtime behaviour changes. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
…doors surfaced (#15267) The narrowing surfaced 68 un-narrowed dereferences, every one in the two packages' own tests: 64 reads of a `findOne()` result with no `null` check (`expect(...).not.toBeNull()` does not narrow), and four reads through the `unknown` that `bulkCreate()`, `explain()` and `findOne()` now resolve to. Each positive control asserts the row arm with vitest's `assert()` — a narrowing assertion, not a `!` and not a cast — and each `unknown` read names what it reads. The not-found controls keep their `toBeNull()` and gain nothing. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
…5267) `SqliteWasmDriver` overrides none of the five doors and inherits every one, so the driver-sql narrowing reaches its callers through that package's `.d.ts`. Eight positive controls assert the row arm; one `create()` read names the string it collects. No source change in this package. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
…rity probe (#15267) `SqlDriver.create()` declares the contract's `Record<string, unknown>` now, so the cross-side parity probe's `rec_no` read is `unknown` where it was reached through an `any`. It converts to the string the probe compares. Caught by this package's `check:test-typecheck` gate, not by `tsc --noEmit` — the file is in the checked test zone and the ledger does not cover it. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
#15267) Both graded `minor` and marked `type-surface-only` under ADR-0087, matching the landed precedent PR #15280 for `update()` on the same classes. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
…-0087 markers (#15267) `isErasedType` counts `unknown` as erased by design (pinned TSO-U6), so the `execute` / `explain` doors — which move onto the contract's own `unknown` — cannot serve as predicate-4 evidence. The markers name the three doors that move onto concrete shapes and state the rest in prose; the disposition is identical for every door. Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a38cec6aeccee4ff813914f732d5a6b50fe24d7e && git checkout a38cec6aeccee4ff813914f732d5a6b50fe24d7e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5a95b0e93d048d96736a5ab678642416b6277c42 07a0244496587000f6ddeccd2d9ddd356b2e7aaa && git checkout -B drift-repro 5a95b0e93d048d96736a5ab678642416b6277c42 && git merge --no-ff 07a0244496587000f6ddeccd2d9ddd356b2e7aaa
node scripts/docs-audit/affected-docs.mjs --json 5a95b0e93d048d96736a5ab678642416b6277c42
|
Fixes #15267
Generic type arguments are written in SQUARE brackets throughout this body (the body sanitizer eats the angle-bracket spelling):
Promise[any]in prose means the angle-bracket form in code.What
SqlDriverpublished an explicitPromise[any]over fiveIDataDriverdoors thatpackages/spec/src/contracts/data-driver.tshad already declared narrower. An explicitanysatisfies every one of them structurally, sotscsaid nothing while the emitted.d.tstold every consumer thatfindOne()never returnsnulland thatcreate()returns whatever they like. #15280 (card #14438) un-maskedupdate()and filed the census of what was left; this is that remainder.Cited by symbol, as the contract declares them:
IDataDriversymbolfindOneIDataDriver.findOnePromise[Record[string, unknown] | null]Promise[any]createIDataDriver.createPromise[Record[string, unknown]]Promise[any]bulkCreateIDataDriver.bulkCreatePromise[Record[string, unknown][]]Promise[any]executeIDataDriver.executePromise[unknown]Promise[any]explainIDataDriver.explain(optional)Promise[unknown]Promise[any]Every one was already declared narrower on the contract before this PR — verified by reading the file, not the card. So no door here is the first place its narrower type is published, and ruling 2's stop-and-report condition did not fire.
No runtime behaviour changes. Each annotation is now the contract's, and each is pinned at the type level inside its own package's tsc program.
Scope correction:
driver-tursooverrides FOUR of the five doors, not oneThe card and the dispatch both record that
TursoDriveroverridescreate()and that this override will not inherit a driver-sql fix. That is true but incomplete. Measured onorigin/main:turso-driver.tsoverridefindOnePromise[any]Promise[Record[string, unknown] | null]createPromise[any]Promise[Record[string, unknown]]bulkCreatePromise[any]Promise[Record[string, unknown][]]executePromise[any]Promise[unknown]Fixing only
createwould have left@objectstack/driver-turso's published.d.tsdeclaringfindOne,bulkCreateandexecuteasanyon its own — the census would read clean while three doors stayed masked. That is precisely the recurrence the triage seat flagged twice, one door wider than it was measured. These are additional SITES of the same five doors, not additional doors:analyzeQuery,aggregate,upsertandbeginTransactionare untouched.The fix is mechanical and provably safe, because both branches of all four already answered the contract's type before this change: the local branch forwards to
super.[door](narrowed here), and the remote branch passesRemoteTransport's result — already declaredRecord[string, unknown] | null,Record[string, unknown],Record[string, unknown][]andunknownrespectively — through the genericformatRemoteRow/formatRemoteRows([T](object, row: T): T). Each override'sPromise[any]was pure erasure with nothing behind it.Semver grade:
minor+ BREAKINGThe dispatch's ruling 2 said
patch, on the reading that the contract already declares these returns so the classes merely stop publishing wider than their own declaration. The seat corrected that grade after measuring the public-entry exports, and the correction is right:SqlDriverandTursoDriverare themselves exported from their packages' public entries (packages/drivers/driver-sql/src/index.ts,packages/drivers/driver-turso/src/index.ts), so a consumer canimport { SqlDriver }and type against the class directly. For that consumer the class's own.d.tsIS the contract, and narrowingfindOnefromPromise[any]toPromise[Record[string, unknown] | null]breaks their build — they now owe anullcheck they did not owe before. That is the published-type-narrowing rung:minor+ BREAKING + anadr-0087:disposition.The landed precedent independently settles it the same way. Ruling 4 asked what #14438 actually did. Measured from PR #15280's merged diff, both of its changesets are
minor, notpatch:.changeset/driver-sql-update-declared-null.md—'@objectstack/driver-sql': minor.changeset/driver-turso-update-declared-null.md—'@objectstack/driver-turso': minorboth bodies opening
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as minor under the launch-window convention (the one PR #14434 used), and both carrying atype-surface-onlyADR-0087 marker. So the precedent and the corrected ruling agree; the originalpatchreading was the outlier, and nothing here had to choose between them.Both changesets are graded
minor, declare BREAKING, and carry atype-surface-onlyADR-0087 disposition. NoClause-②, noneeds:contract-review— matching both ruling 2's standing instruction and the precedent PR's own label set.packages/spec/is untouched.One boundary worth a reviewer's eye
check-adr-0087-registration.mjsverifies atype-surface-onlyclaim with predicate 4, "narrowed-from-erased", and itsisErasedTypecounts bothanyandunknownas erased — pinned deliberately in its self-test (TSO-U5/TSO-U6). Soexecuteandexplain, whose destination is the contract's ownunknown, cannot serve as predicate-4 evidence even though moving them offanygenuinely breaks TypeScript consumers (anunknownrefuses the property readsanyallowed — this PR had to repair exactly such a read). The markers therefore name the three doors that move onto concrete shapes and state the remaining doors in prose; the disposition is identical for all of them. Reported as a finding, not routed around.What the narrowing surfaced — the point of the change
69 un-narrowed dereferences that the
anyhad been hiding, every one in test code, none in production code:driver-sqlfindOne()result with nonullcheck, plus 4 reads through the newunknowndriver-tursoexpect(row).not.toBeNull()followed by a dereferencedriver-sqlite-wasmruntimecreate().rec_noin the autonumber cross-side parity probeThe dominant shape is a positive control that wrote
expect(x).not.toBeNull()and then dereferenced — that assertion does not narrow. Each is repaired with vitest'sassert(), typed as an assertion signature, so it narrows: not a!and not a cast, the spelling PR #15280 established. The not-found controls keep theirtoBeNull()and gained nothing. Reads through the newunknownname what they read (String(...), or a named plan slice forexplain()). No test was skipped, disabled, quarantined or marked.todo.@objectstack/driver-sqlite-wasmoverrides none of the five doors and re-declares no member of its own, so it carries no changeset — the narrowing reaches its consumers through@objectstack/driver-sql's.d.ts. Its test-side repairs are here because the DoD requires fixing callers the narrowing legitimately breaks.Region fence
PR #17221 is open against
sql-driver.tswith hunks at@@ -9473,@@ -9589,@@ -9641; the declared fence is lines 9460–9690. Every hunk in this PR, in merge-base coordinates: 6053, 6055, 6067, 7888, 8431, 8433, 9240. The highest is 220 lines clear of the fence. No open PR touchesturso-driver.ts.Verification
Consumer-closure typecheck — the prefix (downstream) direction, every workspace package that depends on either edited package, with their builds:
Reverse verification, direction predicted before it ran. Prediction: with the nine annotations back at
Promise[any]and the pins present, driver-sql's typecheck fails with exactly 10 TS2322 (5 doors xIsAny+Equals) and driver-turso's with exactly 8 (4 doors x 2), both confined to the pin files. Measured, exactly as predicted:plus one TS7006 per package, in the same pin files, where a runtime case's
mapcallback loses its parameter type oncebulkCreateisanyagain. The mutation was proved on disk before the run was read (5 and 4 annotations atPromise[any], 0 narrowed) and the restore leg is proved by blob hash, not by an exit code:git hash-objecton both files equalsgit rev-parse HEAD:[path], andgit diff HEADis empty. The pins import their subject relatively (./index.js,./turso-driver.js), so they resolve to source rather than through a dependency'sexports— the ablation-dist-preflight hazard's stated condition does not apply, and the RED result is itself the proof of source resolution.Tests — no new failures, nothing skipped:
Gates — the family derived from the diff itself,
node scripts/pm/dispatch-gates.mjs, change set taken off the merge base rather than a hand-written list:Every exit code was captured by redirect before any pipe, never across one.
Lint — the repo-wide run, not a narrowed one:
pnpm lint(eslint . --no-inline-config) exits 0 over the whole population, measured on07a0244.Deliberately not done
analyzeQuery()— not anIDataDrivermember; leftPromise[any]by ruling.aggregate()— leftPromise[any]by ruling. Worth recording that the ruling's stated premise is wrong: the card's census saysaggregate()is "not on the contract", butdata-driver.tsdoes declareaggregate?(object, query, options): Promise[Record[string, unknown][]]. The instruction was to leave it, so it is untouched; the premise is reported rather than acted on.TursoDriver.upsert()—Promise[Record[string, any]], noted-not-counted on the card; untouched.TursoDriver.beginTransaction()—Promise[any]; never in scope.packages/spec/— untouched, per ruling 1.driver-sqlite-wasm(PR feat(driver-sql,driver-turso):update()publishes its honest type — the contract'sRecord[string, unknown] | null, notany(#14438) #15280 added one there forupdate()). Ruling 1 fenced that package out of the diff, and ruling 4's precedent-wins clause is scoped to the grade; its source-level repairs are limited to the callers the narrowing broke.Generated by Claude Code